fix(uploadstore): bound R2 Serve body read - #182
Conversation
R2 ServeHTTP copies the object body with Client.Timeout 0. Response headers are bounded, but a stalled body after headers left the download handler blocked. Wrap the Serve response body with an idle read deadline so a hung origin unblocks the request without changing the streaming-safe client timeout. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
|
🦞👀 Pull request received. I will update this pull request when review starts. |
|
Codex review: needs changes before merge. Reviewed August 29, 2026, 2:59 PM ET / 18:59 UTC. ClawSweeper reviewWhat this changesThe PR adds a 30-second idle deadline around R2 object-body reads so R2-backed upload downloads do not wait indefinitely after response headers arrive. Merge readinessKeep open: the idle timeout bounds the stalled R2 read, but it now returns an error after the upload response has already started, causing the API handler to append a JSON error to a partial file response. Priority: P2 Review scores
Verification
How this fits togetherClickClack’s upload store retrieves R2-backed files for the authenticated upload endpoint. The endpoint starts the client response before streaming the R2 body, and later read errors return through the API handler. flowchart LR
A[Authenticated upload request] --> B[Upload API handler]
B --> C[R2 upload store]
C --> D[R2 response body]
D --> E[Stream bytes to client]
D --> F[Idle-read timeout]
F --> G[Post-header error handling]
Before merge
Findings
Agent review detailsSecurityNone. Review metrics
Merge-risk optionsMaintainer options:
Copy recommended automerge instructionTechnical reviewBest possible solution: Preserve a clean terminated download after a post-header R2 read failure and add an endpoint-level regression test for a partially delivered stalled object. Do we have a high-confidence way to reproduce the issue? Yes: current-main source shows the unbounded post-header io.Copy path, and the submitted trace exercises the corresponding stalled-body scenario against NewR2. Is this the best way to solve the issue? No: bounding the read is appropriate, but the endpoint must not write an HTTP error after file headers and bytes have begun. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found, but no applicable review policy affected this item. Codex review notes: model internal, reasoning high; reviewed against 486fd23545af. LabelsLabel changes:
Label justifications:
EvidenceAcceptance criteria:
What I checked:
Likely related people:
Rank-up movesOptional improvements that raise the rating; they are not merge blockers.
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
|
What Problem This Solves
Fixes an issue where users opening an R2-backed upload (image preview, file download, range seek) would see the request hang forever when Cloudflare R2 accepted the GET, returned headers, and then stopped sending body bytes. The outbound client only bounds the wait for response headers. After headers arrive,
ServeHTTPcopies the body withClient.Timeoutleft at 0, so a stalled origin never unblocks the download handler.This is the R2 serve path used by
GET /api/uploads/:upload_id, not the browserfetchtimeout from #168 and not a change to the streaming-safe overall client timeout.Why This Change Was Made
ServeHTTPnow wraps the R2 response body with an idle read deadline (30s). EachReadthat makes no progress in that window closes the body and returnsr2 serve: body read stalled. Bytes that keep arriving reset the idle timer, so large or slow-but-live objects still stream.http.Client.Timeoutstays 0, matchingTestR2ConfigValidationand the #168 contract. The emptyDefaultTransportfallback is left unchanged.User Impact
A hung R2 object body fails the download after 30s of idle instead of pinning the upload handler until the user disconnects. Successful and progressing streams are unchanged.
Evidence
Before the patch,
ServeHTTPstayed inio.Copyafter the origin flushed headers and sent no body. A 15s process timeout still had the copy blocked:After the patch, the same stalling origin against production
NewR2(default 30s idle,Client.Timeoutstill 0) returns instead of hanging:Client.Timeoutis still 0 (Timeout != 0would failTestR2ConfigValidation). A 50ms idle on the same Serve path returnsr2 serve: body read stalledin 0.10s.Real behavior proof
Behavior or issue addressed: R2
ServeHTTPhung forever when the origin sent response headers and then stalled the object body, becauseClient.Timeoutis 0 and onlyResponseHeaderTimeoutwas set.Real environment tested: macOS Darwin 25.6.0 arm64, go1.27.0, branch
fix/f001-r2-body-deadlineat the patched tree/tmp/oc-pr-clickclack-F001.Exact steps or command run after this patch:
cd /tmp/oc-pr-clickclack-F001 go run ./apps/api/cmd/r2stallproofThe program built
NewR2against a local origin that flushed a 200 withContent-Length: 100and then sent no bytes.Evidence after fix: terminal output from the patched tree:
Observed result after fix: After headers flushed, ServeHTTP returned
r2 serve: body read stalledat 30054ms (the 30s idle bound). It did not remain blocked on the body copy.Client.Timeoutremains 0.What was not tested: Live Cloudflare R2 credentials and a production bucket GET. Save/Delete paths are unchanged.
Related
Client.Timeout == 0for streaming; this PR bounds the Serve body without changing that contractfaacf69(2026-05-17) and header-only bounding inf7a10e9(2026-05-17)